Skip to content

feat(cli,api): difyctl use workspace + member management#36360

Open
lin-snow wants to merge 5 commits into
langgenius:feat/clifrom
lin-snow:feat/cli-workspace
Open

feat(cli,api): difyctl use workspace + member management#36360
lin-snow wants to merge 5 commits into
langgenius:feat/clifrom
lin-snow:feat/cli-workspace

Conversation

@lin-snow
Copy link
Copy Markdown
Contributor

@lin-snow lin-snow commented May 18, 2026

Important

  1. Make sure you have read our contribution guidelines
  2. Ensure there is an associated issue and you have been assigned to it
  3. Use the correct syntax to link this PR: Fixes #<issue number>.

Summary

Adds server-side workspace switching and full member management to difyctl, mirroring the console's /workspaces/current/members surface but bearer-authed with workspace_id on the path.

Backend — five new /openapi/v1/ endpoints, gated by @accept_subjects(ACCOUNT) + @require_workspace_role(...):

Method Path Min role
POST /workspaces/<id>/switch any member
GET /workspaces/<id>/members any member
POST /workspaces/<id>/members admin / owner
PUT /workspaces/<id>/members/<member_id>/role admin / owner
DELETE /workspaces/<id>/members/<member_id> admin / owner

require_workspace_role returns 404 for non-members (matching GET /workspaces/<id> so workspace ids don't leak across tenants) and 403 for insufficient role. Owner is intentionally not assignable via invite or role-update — ownership transfer stays console-only. Domain logic is reused from TenantService / RegisterService as-is; invites still go through invite_new_member so the Celery activation email fires.

Edition-aware quota gate on POST /members: SaaS subscription cap → members.limit_exceeded, EE license cap → workspace_members.license_exceeded. Both return 403 with {code, message, hint} envelope; CE no-ops because FeatureService leaves both flags false unless billing/EE flips them.

CLI — five commands; -o {json,yaml,name,text} on every read and write:

difyctl use    workspace <id>                                    # server-side switch + saveHosts
difyctl get    member  [-w <id>] [-o …]
difyctl create member  --email <e> --role <r> [-w <id>] [-o …]
difyctl set    member  <member-id> --role <r> [-w <id>] [-o …]
difyctl delete member  <member-id> [-w <id>] [-o …]

use workspace strictly orders POST /switch → GET /workspaces → saveHosts; any failure aborts with no local mutation so hosts.yml never diverges. get member flags the caller's row with *. --role is client-enum-validated to normal | admin before any HTTP call. create -o json exposes the invite_url so scripts can dispatch activation links out-of-band without relying on the Celery email.

The old difyctl auth use (pure-local workspace picker) is deleted — its semantics conflict with server-side switch and would only confuse. The "no workspace selected" hint now points at difyctl use workspace <id>.

Tests cover the role-gate decorator, every new endpoint + payload validator, both API clients, and each CLI command runner (incl. the no-fallback-on-switch-failure invariant + the new printer envelopes).

Screenshots

N/A — CLI-only.

Checklist

  • This change requires a documentation update, included: Dify Document
  • I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!)
  • I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
  • I've updated the documentation accordingly.
  • I ran make lint && make type-check (backend) and cd web && pnpm exec vp staged (frontend) to appease the lint gods

@lin-snow lin-snow requested review from a team, QuantumGhost and laipz8200 as code owners May 18, 2026 16:32
@dosubot dosubot Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label May 18, 2026
@lin-snow lin-snow self-assigned this May 18, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 18, 2026

Pyrefly Diff

base → PR
--- /tmp/pyrefly_base.txt	2026-05-20 09:17:15.213255037 +0000
+++ /tmp/pyrefly_pr.txt	2026-05-20 09:17:05.389187421 +0000
@@ -2182,6 +2182,26 @@
   --> tests/unit_tests/controllers/openapi/test_workspaces.py:49:12
 ERROR `in` is not supported between `Literal['GET']` and `None` [not-iterable]
   --> tests/unit_tests/controllers/openapi/test_workspaces.py:50:12
+ERROR Object of class `FunctionType` has no attribute `view_class` [missing-attribute]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:114:12
+ERROR `in` is not supported between `Literal['POST']` and `None` [not-iterable]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:115:12
+ERROR Object of class `FunctionType` has no attribute `view_class` [missing-attribute]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:120:12
+ERROR `in` is not supported between `Literal['GET']` and `None` [not-iterable]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:121:12
+ERROR `in` is not supported between `Literal['POST']` and `None` [not-iterable]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:122:12
+ERROR Object of class `FunctionType` has no attribute `view_class` [missing-attribute]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:127:12
+ERROR `in` is not supported between `Literal['DELETE']` and `None` [not-iterable]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:128:12
+ERROR Object of class `FunctionType` has no attribute `view_class` [missing-attribute]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:133:12
+ERROR `in` is not supported between `Literal['PUT']` and `None` [not-iterable]
+   --> tests/unit_tests/controllers/openapi/test_workspaces_members.py:134:12
+ERROR Object of class `NoneType` has no attribute `json`
+ERROR Object of class `NoneType` has no attribute `json`
 ERROR Cannot index into `Iterable[bytes]` [bad-index]
    --> tests/unit_tests/controllers/service_api/app/test_audio.py:190:16
 ERROR Cannot index into `Response` [bad-index]

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 18, 2026

Pyrefly Type Coverage

Metric Base PR Delta
Type coverage 0.00% 44.39% +44.39%
Strict coverage 0.00% 43.92% +43.92%
Typed symbols 0 22,964 +22,964
Untyped symbols 0 29,076 +29,076
Modules 0 2649 +2,649

@lin-snow lin-snow force-pushed the feat/cli-workspace branch from 508e575 to 0a425bc Compare May 19, 2026 09:16
@github-actions github-actions Bot added the web This relates to changes on the web. label May 19, 2026
@lin-snow lin-snow requested review from wylswz and removed request for a team, QuantumGhost, Yeuoly, crazywoola and laipz8200 May 19, 2026 11:20
lin-snow and others added 5 commits May 20, 2026 10:59
…ted openapi

Adds five bearer-authed endpoints under /openapi/v1/workspaces/<id>/
(switch, members CRUD, role update) gated by a new
@require_workspace_role decorator that returns 404 for non-members
(matching the existing GET /workspaces/<id> convention so workspace
IDs don't leak across tenants) and 403 for insufficient role.
TenantService / RegisterService domain logic is reused as-is — invites
still go through invite_new_member so the Celery activation email
fires for newly-invited addresses. Owner is intentionally not
assignable through invite or role-update; ownership transfer remains
console-only.

CLI gains five commands:

  difyctl use workspace <id>
  difyctl get member [-w <id>] [-o ...]
  difyctl create member --email <e> --role <r> [-w <id>]
  difyctl delete member <member-id> [-w <id>]
  difyctl set member <member-id> --role <r> [-w <id>]

use workspace strictly orders POST /switch -> GET /workspaces ->
saveHosts; any failure aborts with no local mutation so hosts.yml
never diverges from the server. get member marks the calling account
row with '*' (matched via hosts.yml bundle.account.id). --role is
client-enum-validated to normal|admin before any HTTP call.

The old `difyctl auth use` (a pure-local workspace picker) is
removed — its semantics conflict with server-side switch and keeping
it would only confuse. The "no workspace selected" hint now points
at `difyctl use workspace <id>`.
Inline checks on POST /openapi/v1/workspaces/<id>/members for:
- SaaS subscription members.limit (members.limit_exceeded)
- EE license workspace_members cap (workspace_members.license_exceeded)

Envelope {code, message, hint} on the wire body so CLI error-mapper
can surface structured remediation guidance without edition awareness.
EE per-workspace allow_member_invite policy continues via service-layer
check_workspace_member_invite_permission inside invite_new_member.
Reruns pnpm gen-api-contract and pnpm tree:gen after rebasing onto
upstream/feat/cli (which migrated CLI types to @dify/contracts). Adds
the Member* types to the shared contract package and registers the
new CLI commands (use workspace, create/delete/get/set member) in
the build-time command tree.
…Workspace + simplify _member_response

- invite_url is always set server-side (always-non-null URL build path);
  drop the misleading Optional so generated CLI/SDK types stop forcing
  callers through pointless null checks.
- use/workspace: pickWorkspace was used in one of two adjacent shape
  conversions; inline both for symmetry.
- _member_response: TenantAccountRole and AccountStatus are StrEnums —
  the getattr + `if role else ""` defenses are unreachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
create member -o json now surfaces the full MemberInviteResponse —
including invite_url, previously unreachable from the CLI (scripts
had to rely on the Celery activation email). set/delete return a
synthesized {id, role} / {id, deleted: true} payload; the server's
200 is the proof the mutation took, so no extra round-trip and no
race on concurrent role flips.

Each command grew a small *Output class implementing the framework's
FormattedPrintable (text/json) + NamePrintable. run.ts builds it
(colored success line precomputed); index.ts wraps in formatted()
and lets the runner emit. Mirrors get member's existing
table()-envelope pattern. No backend changes, no spec changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lin-snow lin-snow force-pushed the feat/cli-workspace branch from fa3c1d6 to f5ad3b7 Compare May 20, 2026 09:15
@lin-snow lin-snow requested a review from GareArc May 20, 2026 09:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL This PR changes 1000+ lines, ignoring generated files. web This relates to changes on the web.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant